Skip to content

feat: footnotes in the full-screen table view (1.2.0) - #31

Merged
jphan32 merged 7 commits into
devfrom
feat/fullscreen-table-footnotes
Jul 28, 2026
Merged

feat: footnotes in the full-screen table view (1.2.0)#31
jphan32 merged 7 commits into
devfrom
feat/fullscreen-table-footnotes

Conversation

@jphan32

@jphan32 jphan32 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Closes #28.

The problem

A footnote in a table cell — inline ^[note] or referenced [^1] — renders fine in the note but arrives in Lookout's full-screen table view as a dead [1]. TableView.openFullscreen() clones the <table> into a fixed overlay, and everything the footnote mechanism relies on beyond that subtree lives outside it: the definitions, the #fn-…/#fnref-… anchors, and Obsidian's own click handling, which is delegated on the note's preview sizer and never sees the overlay.

How this landed — and what it cost

The first implementation cloned the note's rendered .footnotes list into the overlay. It passed 51 e2e checks and two multi-agent review rounds. In a real vault it did not work at all.

Obsidian does not render off-screen sections. The definition list sits at the very bottom of a note and the table is above it, so the lookup found nothing and every marker fell into the degradation path. The only footnote that worked belonged to the note's last table, where the list happened to be on screen. The e2e suite never caught it because its fixture always had a rendered .footnotes section — the harness encoded the very assumption that was false.

The design was rebuilt on measurements taken from a live vault rather than on inference.

The design

Definitions come from the note's source, never from rendered DOM:

marker's data-footref  →  metadataCache.getFileCache(file).footnotes
                       →  position slices vault.cachedRead(file)
                       →  MarkdownRenderer.render(…)

Nothing depends on what Obsidian happens to have rendered, so a table resolves its footnotes wherever it sits in the note.

Slicing is where this silently breaks, so it is validated on both sides. A reference-style slice carries its own [^id]: label, which is stripped; an inline slice is the body alone and is left untouched; continuation lines are dedented by exactly one indent unit, or a fenced block inside a definition parses as indented code and a mermaid diagram renders as a grey blob. A slice whose shape disagrees with what its id implies is dropped rather than rendered — a cache indexing mid-update would otherwise paste somebody else's text under the marker.

Rendering is async, so the overlay shows the table immediately and appends the section when it arrives. One Component per overlay owns every MarkdownRenderChild and is assigned before the first await, so a close during rendering unloads it; a render that returns to find the field holding a different component bails without touching the DOM.

Back-references are built, not cloned — one per marker occurrence present in the clone — so a dead back-reference cannot exist by construction.

Hovering a marker previews its definition. Obsidian gives a footnote no hover affordance of its own, and in full screen the definition can sit far below a long table.

Live Preview is deliberately not supported

Obsidian parses each block there on its own. A reference-style marker is not rendered inside a table widget at all — the note itself shows it as plain text, so there is nothing to attach to. An inline marker is rendered, but its id is positional within its parse unit, so a widget's [inline0 need not be the note's [inline0; joining on it would render a different footnote's body under the right marker. Those markers are left visible but inert instead. Wrong text is worse than none.

Also fixed

  • The clone no longer copies the live table's id attributes into the document. A table carrying a ^block-id previously appeared twice under the same id while the overlay was open — invalid DOM, and enough to send an in-note #… link to the clone. Ids inside an SVG are deliberately kept: id-scoped <style>, <use> and gradient/clip-path references break without them.
  • Closing from the keyboard returns focus to the inline trigger, which is made visible while focused. Closing with the mouse leaves focus alone, so Space keeps scrolling the note instead of re-opening the overlay through an invisible button.

Compatibility

minAppVersion moves 1.0.0 → 1.6.6, the release that exposes footnotes in the metadata cache. One code path, no DOM fallback.

Verification

npm run lint            PASS
npm run build           PASS
node --check main.js    PASS
node scripts/validate.mjs   PASS
npm run test:e2e        PASS   table 111/111, diagram 9/9

The suite is rebuilt on the measured markup, led by the case this redesign exists for: a note with no .footnotes anywhere that still produces a correct definition list. It also covers reference-prefix stripping, the dedent, inline definitions, repeated references, out-of-order numbering, an id missing from the cache, embeds resolving against the embedded file, Component unload on close, and uncaught page errors.

The tests were mutation-checked rather than trusted. Reverting individual fixes fails the matching assertion each time — neutralising the dedent fails the mermaid-fence check, corrupting the source lookup fails the central regression, removing the tooltip fails the hover check. The previous suite passed 51 checks while the feature was completely broken; this one discriminates.

Manually verified in a real vault, Reading view and Live Preview: inline and reference footnotes, a table at the top of a long note, repeated references, out-of-order numbering, a mermaid diagram inside a definition, embeds and multiple panes, hover preview, and the Live Preview no-footnote behaviour. tests/manual/footnote-vault-check.md is the checklist that was walked.

Review history

15 findings from a workflow-backed review at xhigh effort were adjudicated against the code and against Obsidian's own renderer extracted from the app bundle: 11 fixed, 4 rejected with evidence (see the comments below). Those fixes — id hygiene, focus handling, the single inert treatment, the auxclick guard — all carried forward into the redesign.

🤖 Generated with Claude Code

jphan32 and others added 3 commits July 27, 2026 22:32
A footnote marker in a table cell survived into the full-screen view as a
dead "[1]": the definitions live in the note's `.footnotes` list, outside the
`<table>` the overlay clones, so there was nothing to show and nothing to
jump to.

The overlay now carries in a copy of every definition the clone's markers
actually point at, lists it under the table, and re-points each footnote id
and `#`-anchor into an overlay-local namespace — so the marker jumps to its
definition and the "↩︎" back-reference jumps back, without leaving full
screen and without disturbing the note's own footnote navigation. The
numbering is carried over explicitly, so a table referencing only footnote 3
still reads "3.", and the section is laid out at a readable measure instead
of the table's width.

Every failure path degrades to the overlay as it was built before: no
markers, no note root, no rendered definition list (Live Preview renders a
table in isolation; Reading view virtualises off-screen sections away), or
nothing that resolves. An individually unresolvable marker in an otherwise
resolved note stays visible but is made inert — the same thing the note
itself shows in those modes.

Clone hygiene also drops the `id` attributes the clone copied over, keeping
only the footnote identity nodes that get re-minted. A table carrying a
`^block-id` previously appeared twice under the same id while the overlay
was open.

Closes #28

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
manifest.json, versions.json and CHANGELOG.md in lockstep, per
scripts/validate.mjs.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A workflow-backed review at xhigh effort raised 15 findings against the
footnote work. Eleven were adjudicated real and are fixed here; four were
rejected with evidence (see below).

Clone hygiene
- `_scrub` no longer strips ids inside an SVG subtree. Removing them broke
  id-scoped `<style>`, `<use>` and gradient/marker/clip-path references
  outright — a Mermaid diagram in a cell rendered unstyled in the overlay —
  whereas a duplicated svg-internal id is benign. The test is on
  `namespaceURI`, not `closest("svg")`, so HTML inside a `<foreignObject>`
  is still stripped.
- `_scrub` now also clears the inline sizing `DiagramView` stamps on a wrapped
  svg before unwrapping the viewport that clipped it. A 1600px diagram inside
  a cloned footnote definition used to render unscaled and overflow the
  overlay.

Anchors
- The "cannot be resolved here" treatment is now a single site, `_makeInert`,
  reached only through `_hardenAnchors`, which sweeps every footnote anchor —
  back-references included. A definition carries one back-ref per reference
  occurrence, including occurrences outside the table, so those extra
  back-refs are always dead in the overlay; left live they looked clickable,
  did nothing, and let the browser dirty the window URL and push a history
  entry.
- The degradation paths (no definition list at all, nothing resolvable) now
  run `_degradeFootnotes`, which strips the footnote identities `_scrub` was
  holding for `_namespace` and makes the markers inert. Previously that clone
  reached the document carrying the note's own ids — duplicate ids for as long
  as the overlay was open — and a click fell through to the note behind it.
- `onFsAnchorClick` ignores the secondary button on `auxclick`. A right-click
  on a marker used to get the context menu *and* a scroll and focus steal.
- The definition list is emitted in the note's document order, not
  first-reference order, so a table referencing [^2] before [^1] no longer
  renders "2." above "1.".

Focus, and accessibility
- Focus returns to the inline trigger only when the view was closed from the
  keyboard. After a mouse close the trigger was focused while
  `:focus-visible` did not match — invisible at `opacity: 0` — so the next
  Space re-opened the overlay instead of scrolling the note. A focused trigger
  is now always visible as well.
- An inert marker keeps an accessible name: an href-less `<a>` computes role
  `generic`, where `aria-label` is prohibited, so `role="link"` is restored.
  The name contains the visible "[1]" rather than replacing it, and a
  back-ref — whose visible text is a glyph that names nothing — says what is
  missing instead.
- The inert state no longer rests on colour alone (dotted underline), and a
  keyboard-driven jump keeps a focus ring after the 1400ms highlight expires.
- Footnote prose is capped at 70ch, so a 2200px table no longer lays a
  one-sentence footnote out as a single 2200px line.

Consistency
- The new code uses `classList` like the rest of the file; `addClass` is no
  longer called. The e2e stub gained the polyfill anyway, since its absence is
  what let the jump path throw under test while working in the app.

Rejected, with evidence: the widened backdrop-close test (those strips already
closed the overlay before this change — only the 20px gap band is new);
duplicate ids from a repeated reference (Obsidian mints a distinct
`data-footnote-id` per occurrence, and the preview post-processor sets each
element's id equal to it); a block id landing on a definition `<li>` (that
post-processor overwrites any pre-existing id, and the block-id transformer
does not reach a synthesized footnote list item); and `(ol.start || 1)`
mishandling `start="0"` (the renderer hard-codes `start: 1`).

Tests: the table suite now drives the behaviour instead of only measuring
layout — it clicks markers and back-references, asserts no page errors, and
covers the degraded, mixed, out-of-order, svg-in-cell and diagram-in-footnote
shapes. 51 table checks + 9 diagram checks.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@jphan32

jphan32 commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Review remediation

A workflow-backed review at xhigh effort raised 15 findings. Each was independently adjudicated against the code and against Obsidian's own renderer extracted from the app bundle, with the empirically testable ones reproduced in headless Chromium driving the real bundled main.js.

11 fixed, 4 rejected with evidence.

Finding Outcome
F0 _scrub wiped SVG-internal ids, breaking url(#…) / id-scoped <style> fixed — namespace test, not closest("svg"), so <foreignObject> HTML is still stripped
F1 Degradation paths shipped the note's live hrefs and ids, with no handler fixed — _degradeFootnotes
F2 Out-of-table back-refs stayed clickable and dirtied the URL fixed — hardening sweeps FN_ANCHOR, not just markers
F3 auxclick jumped on right-click fixed — secondary button ignored
F4 Mouse close parked focus on an invisible button; Space re-opened fixed — keyboard-only restore, and a focused trigger is visible
F6 Cloned diagram kept DiagramView's inline sizing after unwrap fixed
F7 Definition list could render in descending order fixed — emitted in the note's document order
F8 e2e stub lacked addClass; the jump path threw only under test fixed — classList throughout, polyfill added anyway
F9 Inert marker exposed no accessible name fixed — role="link" restored, name contains the visible [1]
F10 outline: none left a focused <li> with no lasting indicator fixed — :focus-visible ring
F11 A wide table stretched footnote prose to the table's measure fixed — capped at 70ch

Rejected

  • F5 (backdrop-close on the wrapper) — the geometric premise is right, but those side strips belonged to scroll before this change and the condition already closed on them. Only the 20px gap band is new; the comment that wrongly claimed the wrapper "hugs the table" was corrected.
  • F12 (duplicate ids from a repeated reference) — Obsidian mints a distinct data-footnote-id per occurrence (fnref-1, fnref-1-1, …) and the preview post-processor sets each element's id equal to its own, so two markers can never collide. Probed with realistic markup: zero duplicates.
  • F13 (a block id on a definition <li>) — that same post-processor unconditionally overwrites any pre-existing id with data-footnote-id + docId, and the block-id transformer only reaches root-level nodes and real list items, never the synthesized footnote item.
  • F14 ((ol.start || 1) vs start="0") — the arithmetic consequence is real, but the renderer hard-codes start: 1; only markup Obsidian cannot produce reaches it.

Note on the fixture

Adjudicating F1 surfaced something the first pass had wrong: beyond the markdown transformer, Obsidian's preview post-processor suffixes every footnote id and #-href with the note's docId and copies data-footnote-id onto the element as a real id. The e2e fixture now mirrors that, which is why the degraded clone genuinely carried duplicate ids.

Verification

npm run lint            PASS
npm run build           PASS
node --check main.js    PASS
node scripts/validate.mjs   PASS
npm run test:e2e        PASS   table 51/51, diagram 9/9

The table suite now drives behaviour rather than only measuring layout: it clicks markers and back-references, fails on any uncaught page error, and covers the degraded, mixed, out-of-order, svg-in-cell and diagram-in-footnote shapes. Every fixed finding was proved closed by measuring the failure against a pre-fix bundle and then against the current one.

Known, deliberately deferred

  • The full-screen overlay has no modal semantics and no focus trap, so Tab can leave it. This predates the footnote work and applies to the diagram overlay too — a proper fix belongs in its own change.
  • The highlight timer uses window.setTimeout, consistent with the rest of the file; switching the file to activeWindow is a separate sweep.

Important

Still not verified in a real vault. Reading view and Live Preview both need a manual pass before merge.

jphan32 and others added 3 commits July 28, 2026 09:20
… DOM

Real-vault testing showed the feature never worked. The overlay read the
note's rendered `.footnotes` list, but Obsidian does not render off-screen
sections: the definition list sits at the very bottom of a note and the table
is above it, so the lookup found nothing and every marker fell into the
degradation path. The only footnote that worked belonged to the note's last
table, where the list happened to be on screen.

The e2e suite passed 51 checks throughout, because its fixture always had a
rendered `.footnotes` section — the harness encoded the very assumption that
is false.

Definitions now come from the note's source. The marker's `data-footref`
joins to `metadataCache.getFileCache(file).footnotes`, the definition's
`position` slices `vault.cachedRead(file)`, and the result is rendered with
`MarkdownRenderer.render` into the overlay. Nothing depends on what Obsidian
happens to have rendered, so a table resolves its footnotes wherever it sits.

Slicing is where this silently breaks, so it is validated on both sides:
- a reference-style slice carries its own `[^id]:` label, which is stripped;
  an inline slice is the body alone and is left untouched;
- continuation lines are dedented by exactly one indent unit, or a fenced
  block inside a definition parses as indented code and a mermaid diagram
  renders as a grey blob;
- a slice that does not match the shape its id implies is dropped rather than
  rendered — a cache indexing mid-update would otherwise paste somebody
  else's text under the marker.

Live Preview is not supported, and this is deliberate. Obsidian parses each
block there on its own: a reference-style marker is not rendered inside a
table widget at all, and an inline marker's id is positional within its parse
unit, so a widget's `[inline0` need not be the note's `[inline0`. Joining on
it would render a different footnote's body under the right marker. Those
markers are left visible but inert instead — wrong text is worse than none.

Rendering is async, so `openFullscreen` shows the table immediately and
appends the section when it arrives. One `Component` per overlay owns every
MarkdownRenderChild and is assigned before the first await, so a close during
rendering unloads it; a render that returns to find the field holding a
different component bails without touching the DOM.

Back-references are now built rather than cloned, one per marker occurrence
present in the clone, so a dead back-ref cannot exist by construction.

minAppVersion moves to 1.6.6, the release that exposes footnotes in the
metadata cache. The version stays 1.2.0.

Tests: the suite is rebuilt on the measured reality, led by the case this
redesign exists for — a note with no `.footnotes` anywhere that still
produces a correct definition list. 109 table checks. Verified by mutation
that they discriminate: neutralising the dedent fails the mermaid-fence
check, and corrupting the source lookup fails the central regression.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Obsidian gives a footnote no hover affordance of its own — its only footnote
handler is a click delegated on the note's preview sizer, which never sees
this overlay — and in full screen the definition can sit far below a long
table, so a marker was a number with nothing behind it until clicked.

The marker now carries the rendered definition as a `title`: native, no
listener, nothing to clean up on close. Read before the back-references are
appended so the preview does not trail a "↩︎" that means nothing out of
context, and capped at 300 characters because a native tooltip does not
scroll — the full text is one click away in the list below the table.

Only the clone is touched; the note's own markers keep no tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@jphan32 jphan32 changed the title feat: support footnotes in the full-screen table view (1.2.0) feat: footnotes in the full-screen table view (1.2.0) Jul 28, 2026
@jphan32

jphan32 commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Real-vault verification — complete

The DOM-clone design shipped in the first two commits of this PR did not work in a real vault, and the e2e suite could not have caught it: its fixture always had a rendered .footnotes section, which is exactly the assumption that is false. Obsidian does not render off-screen sections, and the footnote definition list sits at the bottom of a note while the table sits above it.

Measured with the plugin loaded in a live vault:

Situation document.querySelectorAll(".footnotes").length
Live Preview, any scroll position 0
Reading view, top of the note 0
Reading view, scrolled to the very bottom 1 (9 li)

Only the note's last table ever worked, because the list happened to be on screen when it opened. Not a coincidence — a necessity.

Redesign

Definitions now come from the note's source: data-footrefmetadataCache.getFileCache(file).footnotesposition slices vault.cachedRead(file)MarkdownRenderer.render. Details in the PR description.

Three facts that came out of the vault and shaped the design, none of which were inferable from the typings:

  • position differs by footnote kind. An inline slice is the body alone; a reference-style slice carries its own [^id]: prefix; a multi-line definition indents its continuation lines by 4 spaces, so a fenced block needs dedenting or it parses as indented code and a mermaid diagram renders as a grey blob.
  • Inline footnote ids are positional per parse unit. In Live Preview each block is parsed on its own, so a widget's [inline0 need not be the note's [inline0 — joining on it would put a different footnote's body under the right marker. Live Preview is therefore not supported, which reverses the direction this PR was originally taking.
  • Obsidian has no footnote hover affordance at all. Its only footnote handler is a click delegated on the note's preview sizer. The tooltip that appeared in the broken build was this PR's own "not found" title, not an Obsidian feature — so the hover preview is added deliberately rather than restored.

Verification

Gate green; e2e 111 table + 9 diagram. The suite was mutation-checked rather than trusted — reverting the dedent, the source lookup, or the tooltip each fails the matching assertion.

Manual vault pass complete across Reading view and Live Preview: inline and reference footnotes, a table at the top of a long note, repeated references, out-of-order numbering, mermaid inside a definition, embeds and multiple panes, hover preview, and the Live Preview no-footnote behaviour. Checklist: tests/manual/footnote-vault-check.md.

Still outstanding

  • No human review. Everything above was produced and checked by automated pipelines; the 15 findings show that self-review alone was not sufficient.
  • minAppVersion 1.0.0 → 1.6.6 excludes older Obsidian from installing or updating.
  • Deferred, pre-existing and out of scope here: the full-screen overlay has no modal semantics or focus trap (applies to the diagram overlay too), and the file uses window.setTimeout where activeWindow would be the convention.

A full-screen overlay covers the workspace, but Tab walked straight out of it
onto the note's own links and buttons behind the overlay: focus landed where
the reader could not see it, and the next Enter activated it. Measured with
the trap disabled — Tab reaches BODY and then the note's own footnote links,
underneath a still-open overlay.

Tab and Shift+Tab now cycle within the overlay, and it identifies itself as a
labelled modal dialog. This applies to the diagram overlay as well as the
table one; it was never specific to footnotes.

The focusable set is deliberately narrow. Footnote definitions carry
`tabindex="-1"` as programmatic jump targets and stay out of the tab order,
and an unresolvable marker has had its `href` stripped, which already makes it
unfocusable. Visibility is tested with `getClientRects()`, not `offsetParent`,
because the close button is `position: fixed` and its `offsetParent` is always
null. The table overlay reads its focusables at keypress time rather than at
open time, since footnote definitions render asynchronously and arrive after
the overlay is already up.

Obsidian's own modals set neither `role="dialog"` nor `aria-modal` — verified
in the app bundle — but they are small centred dialogs a reader can see past.
Ours is the whole screen.

Also swept `window` to `activeWindow` where it is correct: `matchMedia` for
the reduced-motion probe and `innerHeight` for the inline height cap. The
timer functions are deliberately NOT swept: `eslint-plugin-obsidianmd`'s
`prefer-window-timers` rule requires `window.setTimeout` /
`window.clearTimeout` / `window.requestAnimationFrame`, so the existing calls
were already correct and converting them fails lint.

Tests: 4 checks per suite covering the dialog attributes, that Tab and
Shift+Tab never escape, that Tab genuinely cycles through more than one stop
(the footnoted table is used on purpose — a plain table offers only the close
button and would pass without testing anything), and that Esc returns focus to
the inline frame. Verified by mutation that disabling the trap fails them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@jphan32

jphan32 commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Deferred items resolved in this PR

Both were previously listed as out of scope; they are now done.

1. Modal semantics + focus trap — real defect, fixed

A full-screen overlay covers the workspace, but Tab walked straight out of it. Measured with the trap disabled:

table overlay:    escaped=6   BODY → A.footnote-link → A.footnote-link …
diagram overlay:  escaped=2   … → BODY → DIV.lookout-viewport

Those A.footnote-link hits are the note's own markers, underneath a still-open overlay — focus landing where the reader cannot see it, one Enter away from activating.

Tab/Shift+Tab now cycle inside the overlay, and it identifies itself as a labelled modal dialog. This applies to the diagram overlay too; it was never specific to footnotes.

Details worth noting: the focusable set is narrow on purpose (footnote definitions hold tabindex="-1" as jump targets and stay out of the tab order; an inert marker has no href and is already unfocusable); visibility is tested with getClientRects() rather than offsetParent, because the close button is position: fixed and its offsetParent is always null; and the table overlay reads its focusables at keypress time, since footnote definitions render asynchronously and arrive after the overlay is up.

Obsidian's own modals set neither role="dialog" nor aria-modal — verified in the app bundle — but they are small centred dialogs a reader can see past. Ours is the whole screen.

2. windowactiveWindow — the finding was wrong

eslint-plugin-obsidianmd has a prefer-window-timers rule that requires window.setTimeout / window.clearTimeout / window.requestAnimationFrame. Converting them produced 11 lint errors:

error  Use 'window.setTimeout()' instead of 'activeWindow.setTimeout()'.
       Timer functions should use 'window'    obsidianmd/prefer-window-timers

The existing calls were already correct, and CLAUDE.md names lint as the authority on Obsidian compliance. The sweep was therefore limited to the two places where activeWindow genuinely is right and lint agrees: matchMedia for the reduced-motion probe and innerHeight for the inline height cap.

Verification

Gate green. e2e 119 checks (table 106, diagram 13) — 8 new, 4 per suite: the dialog attributes, Tab never escaping, Shift+Tab never escaping, Tab genuinely cycling through more than one stop, and Esc returning focus to the inline frame.

The cycling check uses the footnoted table deliberately: a plain table's overlay offers only the close button, so a trap check against it would pass without ever testing the trap. Mutation-verified — disabling the trap fails these checks in both suites.

minAppVersion 1.6.6 confirmed by the maintainer.

@jphan32
jphan32 merged commit f433c9a into dev Jul 28, 2026
1 check passed
@jphan32
jphan32 deleted the feat/fullscreen-table-footnotes branch July 28, 2026 12:01
@jphan32 jphan32 mentioned this pull request Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support inline footnotes in full-screen table view

1 participant